﻿// AUTOINPC for MVVM Light
// Author : Olivier Dahan odahan@gmail www.e-naxos.com/blog "DotBlog"

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
using GalaSoft.MvvmLight;


namespace DotBlog.MvvmTools
{
    public class AutoInpc
    {
        /// <summary>
        /// List of fields triggering an update and for each the list of computed fields that must fire INPC
        /// </summary>
        private readonly Dictionary<string, List<string>> triggerDefs = new Dictionary<string, List<string>>();

        /// <summary>
        /// The Method info about the RaisePropertyChanged of the ObservableObject
        /// </summary>
        private MethodInfo simpleRaiseInpcMethod;

        /// <summary>
        /// List of fields waiting for an INPC in suspended mode
        /// </summary>
        private readonly Dictionary<string,object> waitingList = new Dictionary<string, object>();

        private bool isSuspended;

        /// <summary>
        /// Call this static method to create an AutoInpc instance that is bound to
        /// a specific ObservableObject from Mvvm Light (a ViewModelBase is an ObservalbleObject or any
        /// Model object built using this parent class).
        /// </summary>
        /// <param name="vm">The ObservableObject or ViewModelBase</param>
        /// <returns>The instance of the AutoInpc object. It is a good idea to keep a token on this object.</returns>
        public static AutoInpc Initialize(ObservableObject vm)
        {
            if (vm == null) return null;
            var auto = new AutoInpc();
            auto.checkAttributes(vm);
            auto.getMethodInfo();
            auto.hackInpc(vm);
            return auto;
        }

        /// <summary>
        /// True when autoInpc activity is suspended.
        /// </summary>
        public bool IsSuspended
        {
            get { return isSuspended; }
            set
            {
                if (value==isSuspended) return;
                isSuspended = value;
                if (!value) fireWaitingInpc();
            }
        }

        /// <summary>
        /// One can't directly create an instance. Use static Initialize() method.
        /// </summary>
        private AutoInpc() { }

        /// <summary>
        /// Plugs the AutoInpc instance into the INPC event of the ObservableObject
        /// </summary>
        /// <param name="vm">The ObservableObject (ViewModelBase...)</param>
        private void hackInpc(INotifyPropertyChanged vm)
        {
            vm.PropertyChanged += vmPropertyChanged;
        }

        /// <summary>
        /// Event handler for the ObservableObject.
        /// </summary>
        /// <param name="sender">The ObservableObject instance</param>
        /// <param name="e">INPC event args</param>
        private void vmPropertyChanged(object sender, PropertyChangedEventArgs e)
        {
            if (!triggerDefs.ContainsKey(e.PropertyName)) return;
            if (sender==null) throw new Exception("Sender of INPC event is null.");
            var vm = sender as ObservableObject;
            if (vm == null) throw new Exception(
                string.Format("Sender of INPC event is not an ObservableObject. Property: {0}. Sender type: {1}",e.PropertyName,sender.GetType().Name));
            foreach (var field in triggerDefs[e.PropertyName])
            {
                if (IsSuspended)
                {
                    if (!waitingList.ContainsKey(field))
                    {
                        waitingList.Add(field, sender);
                    }
                    continue;
                }
                simpleRaiseInpcMethod?.Invoke(sender, new object[] {field});
            }
        }


        /// <summary>
        /// Fires all waiting INPC since activity has been suspended
        /// </summary>
        private void fireWaitingInpc()
        {
            if (waitingList.Count < 1) return;
            foreach (var waitingEvent in waitingList)
            {
                simpleRaiseInpcMethod?.Invoke(waitingEvent.Value, new object[] {waitingEvent.Key});
            }
            waitingList.Clear();
        }

        // Get the method information for "RaisePropertyChanged(string)" in ObservableObject
        // This method is protected so we must use reflection to invoke it.
        // There are many overloads, only one is used here, the simplest using the property name.
        // It will be interesting to also support the overload with the broadcast option. Next version ?
        private void getMethodInfo()
        {
            var t = typeof(ObservableObject);
            var methods = t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic);
            simpleRaiseInpcMethod =
                (from methodInfo in methods
                 where methodInfo.Name == "RaisePropertyChanged"
                 let pp = methodInfo.GetParameters()
                 where (pp.Length == 1) && (pp[0].ParameterType.Name == "String")
                 select methodInfo).FirstOrDefault();
            if (simpleRaiseInpcMethod == null) throw new Exception("RaisePropertyChanged(String) method can't be found is sender.");
        }

        /// <summary>
        /// Checks each public properties of the source ObservableObject for the
        /// support of our special attribute "ComputedFieldAttribute".
        /// When the attribute is found, the property and the depending ones are
        /// extracted and placed in a dictionary.
        /// The attribute is used against a computed field, and it lists the fields triggering the latter.
        /// </summary>
        /// <param name="vm">The ObservableObkect (ViewModelbase...)</param>
        private void checkAttributes(ObservableObject vm)
        {
            var t = vm.GetType();
            var pi = t.GetProperties(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public);
            foreach (var propertyInfo in pi)
            {
                ComputedFieldAttribute att;
                if (!tryGetAttribute(propertyInfo, out att)) continue;
                foreach (var field in att.TriggerFields)
                {
                    vm.VerifyPropertyName(field);
                    var targets = new List<string>();
                    if (triggerDefs.ContainsKey(field)) targets = triggerDefs[field];
                    else triggerDefs.Add(field, targets);
                    if (targets.IndexOf(propertyInfo.Name) == -1)
                        targets.Add(propertyInfo.Name);
                }
            }
        }

        /// <summary>
        /// Gets a given attribute from a property (its MemberInfo)
        /// </summary>
        /// <typeparam name="T">Type of the attribute to return if any</typeparam>
        /// <param name="memberInfo">The MemberInfo for the property</param>
        /// <param name="customAttribute">The returned attribute if any</param>
        /// <returns>true if attributes is found; else false</returns>
        private static bool tryGetAttribute<T>(MemberInfo memberInfo, out T customAttribute) where T : Attribute
        {
            var attributes = memberInfo.GetCustomAttributes(typeof(T), false).FirstOrDefault();
            if (attributes == null)
            {
                customAttribute = null;
                return false;
            }
            customAttribute = (T)attributes;
            return true;
        }
    }
}
